import pickle
import pandas as pd
import numpy as np
from datetime import datetime
import plotly.express as px
from collections import defaultdict, namedtuple
%%time
players = pd.DataFrame(pickle.load(open('players.pkl', 'rb')).values())
cups = pd.DataFrame(pickle.load(open('tournaments.pkl', 'rb')).values())
cups['year'] = cups.dateStart.apply(lambda x: int(x[:4]))
cups = cups[(cups.year == 2019) | (cups.year == 2020)]
cups_present = {x for x in cups.id}
results = {}
for cup_id, values in pickle.load(open('results.pkl', 'rb')).items():
if cup_id not in cups_present:
continue
players_results = {}
teams_results = {}
number_of_teams = len(values)
questions_number = 0
for team in values:
if 'mask' not in team or not team['mask']:
continue
if 'position' not in team or not team['position']:
continue
position = team['position']
team_id = team['team']['id']
questions_number = questions_number or len(team['mask'])
players_results.update({
player['player']['id']: {
'answer': team['mask'],
'position': position / number_of_teams,
'team': team_id
} for player in team['teamMembers']})
teams_results[team_id] = position
if players_results:
results[cup_id] = dict(
players_results=players_results,
teams_results=teams_results,
questions_number=questions_number
)
cups_present = cups_present & set(results.keys())
cups = cups[cups.id.isin( cups_present)].reset_index(drop=True)
cups.dateStart = cups.dateStart.apply(datetime.fromisoformat)
cups.dateEnd = cups.dateEnd.apply(datetime.fromisoformat)
# results = pd.DataFrame(pickle.load(open('results.pkl', 'rb')).values())
CPU times: user 13.4 s, sys: 2.33 s, total: 15.7 s Wall time: 16.3 s
В качестве бейзлайна обучим логистическую регрессию, которая должна будет предсказывать для данного игрока и данного вопроса вероятнсоть его правильного ответа на него. Для этого сформируем вектор признаков вопроса и вектор признаков, характеризующих игрока.
Так как единственная информация, доступная нам про вопрос, это характеристика турнира, то будем описывать вопрос признаками, соответствующими турниру:
Замечание: можно было бы конечно использовать данные об ответе на вопрос (единственная информация, которая нам доступна по-вопросно из данных. Например, отношение правильных ответов к общему числу ответов на вопрос. Однако, с точки зрения модели такое делать некорректно: возникает утечка данных (подглядывание в ответы). Например, если бы доля правильных ответов равнялась 0, то это подсказало бы модели, что правильный ответ 0.
Вектор же игрока будем описывать его текущими достижениями:
Выборка будет формироваться следующим образом: для каждого турнира, для каждого участника, и для каждого вопроса мы добавляем вектор фичей и лейбл. После турнира пересчитывааем вектор признаков участника: в след турнире у него обновится вектор результатов. Турниры будем перебирать в порядке возрастания.
Для проверки результатов - зафиксируем вектора участников по состоянию на конец 2019 года. Каждый турнир 2020 года будем рассматривать независимо.
cups['competitors'] = cups.id.apply(lambda x: len(results[x]))
cups['competitors'] /= cups.competitors.max()
cups['cup_type'] = cups.type.apply(lambda x: x['id'])
cups['duration'] = (cups.dateEnd - cups.dateStart).apply(lambda x: x.total_seconds() / 3600)
cups['duration'] /= cups.duration.max()
cups_features = cups[['id', 'competitors', 'cup_type', 'duration']].copy().set_index('id')
cups_features = pd.concat([cups_features, pd.get_dummies(cups_features.cup_type, prefix='type')], axis=1)
cups_features.drop(columns='cup_type', inplace=True)
def get_cup_features(cup_id):
global cups_features
return cups_features.loc[cup_id, :].values
cups_features.head()
| competitors | duration | type_2 | type_3 | type_5 | type_6 | type_8 | |
|---|---|---|---|---|---|---|---|
| id | |||||||
| 4772 | 1.0 | 0.011545 | 0 | 1 | 0 | 0 | 0 |
| 4957 | 1.0 | 0.020084 | 0 | 1 | 0 | 0 | 0 |
| 4973 | 1.0 | 0.011535 | 0 | 1 | 0 | 0 | 0 |
| 4974 | 1.0 | 0.011535 | 0 | 1 | 0 | 0 | 0 |
| 4975 | 1.0 | 0.011535 | 0 | 1 | 0 | 0 | 0 |
players.head()
| id | name | patronymic | surname | |
|---|---|---|---|---|
| 0 | 1 | Алексей | None | Абабилов |
| 1 | 10 | Игорь | Абалов | |
| 2 | 11 | Наталья | Юрьевна | Абалымова |
| 3 | 12 | Артур | Евгеньевич | Абальян |
| 4 | 13 | Эрик | Евгеньевич | Абальян |
class PlayerFeatures(object):
def __init__(self):
self.cups_n = 0
self.questions_n = 0
self.correct_n = 0
self.sum_position = 0.
self.sum_square_position = 0.
self.min_position = 1.
self.max_position = 0.
self.cups = defaultdict(int)
def __call__(self, total_cups, total_questions):
'''
количество турниров, в которых принимал участие (деленное на общее количество турниров)
количество турниров, в которых принимал участие, в лог шкале
количество вопросов, на которые пытался дать ответ, (деленное на общее количество вопросов)
количество вопросов, на которые пытался дать ответ, в лог шкале
среднее число правильных ответов
среднее место, которое занимал участник в турнире (нормированное)
дисперсия места в турнире
максимальная позиция в турнире (нормированная)
минимальная позиция в турнире (нормированная)
'''
features = np.zeros(9)
if self.cups_n == 0:
return features
average_position = self.sum_position / self.cups_n
std = np.power((self.sum_square_position - self.cups_n * np.power(average_position, 2))/self.cups_n, 0.5)
features[0] = self.cups_n / total_cups
features[1] = np.log(self.cups_n)
features[2] = self.questions_n / total_questions
features[3] = np.log(self.questions_n)
features[4] = self.correct_n / self.questions_n
features[5] = average_position
features[6] = std
features[7] = self.max_position
features[8] = self.min_position
return features
def update(self, cup_id, cup_position, answers):
self.cups[cup_id] += 1
self.cups_n += 1
self.questions_n += len(answers)
self.correct_n += answers.count('1')
self.sum_position += cup_position
self.sum_square_position += np.power(cup_position, 2)
self.min_position = min(self.min_position, cup_position)
self.max_position = max(self.max_position, cup_position)
PLAYER_FEATURES = defaultdict(PlayerFeatures)
def reset_player_features():
global PLAYER_FEATURES
PLAYER_FEATURES = defaultdict(PlayerFeatures)
def update_features(player_id, cup_id, cup_position, answers):
PLAYER_FEATURES[player_id].update(cup_id, cup_position, answers)
def get_player_features(player_id, total_cups, total_questions):
return PLAYER_FEATURES[player_id](total_cups, total_questions)
cups.head()
| id | name | dateStart | dateEnd | type | season | orgcommittee | synchData | questionQty | year | competitors | cup_type | duration | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 4772 | Синхрон северных стран. Зимний выпуск | 2019-01-05 19:00:00+03:00 | 2019-01-09 19:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/52 | [{'id': 28379, 'name': 'Константин', 'patronym... | {'dateRequestsAllowedTo': '2019-01-09T23:59:59... | {'1': 12, '2': 12, '3': 12} | 2019 | 1.0 | 3 | 0.011545 |
| 1 | 4957 | Синхрон Биркиркары | 2020-02-21 00:00:00+03:00 | 2020-02-27 23:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/53 | [{'id': 2421, 'name': 'Ася', 'patronymic': 'Се... | {'dateRequestsAllowedTo': '2020-02-27T18:00:00... | {'1': 13, '2': 13, '3': 13} | 2020 | 1.0 | 3 | 0.020084 |
| 2 | 4973 | Балтийский Берег. 3 игра | 2019-01-25 19:05:00+03:00 | 2019-01-29 19:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/52 | [{'id': 23030, 'name': 'Марина', 'patronymic':... | {'dateRequestsAllowedTo': '2019-01-28T23:59:59... | {'1': 12, '2': 12, '3': 12} | 2019 | 1.0 | 3 | 0.011535 |
| 3 | 4974 | Балтийский Берег. 4 игра | 2019-03-01 19:05:00+03:00 | 2019-03-05 19:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/52 | [{'id': 23030, 'name': 'Марина', 'patronymic':... | {'dateRequestsAllowedTo': '2019-03-04T23:59:59... | {'1': 12, '2': 12, '3': 12} | 2019 | 1.0 | 3 | 0.011535 |
| 4 | 4975 | Балтийский Берег. 5 игра | 2019-04-05 19:05:00+03:00 | 2019-04-09 19:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/52 | [{'id': 23030, 'name': 'Марина', 'patronymic':... | {'dateRequestsAllowedTo': '2019-04-08T23:59:59... | {'1': 12, '2': 12, '3': 12} | 2019 | 1.0 | 3 | 0.011535 |
Так как мы не разлтичаем по признакам вопросы с одного турнира между собой, то будем предсказывать долю правильных ответов на вопросы для турнира
def generate_data(df, do_update, total_cups=None, total_questions=None):
X, y = [], []
if do_update:
total_cups, total_questions = 0, 0
for cup_id in df.sort_values('dateStart').id:
cup_features = get_cup_features(cup_id)
if do_update:
total_cups += 1
total_questions += results[cup_id]['questions_number']
for player_id, player_data in results[cup_id]['players_results'].items():
player_features = get_player_features(player_id, total_cups, total_questions)
label = player_data['answer'].count('1') / len(player_data['answer'])
X.append(np.append(cup_features, player_features))
y.append(label)
if do_update:
update_features(player_id, cup_id, player_data['position'], player_data['answer'])
return np.array(X), np.array(y), total_cups, total_questions
%%time
reset_player_features()
X_train, y_train, total_cups, total_questions = generate_data(cups[cups.year == 2019], do_update=True)
X_test, y_test, _, _ = generate_data(cups[cups.year == 2020], do_update=False,
total_cups=total_cups, total_questions=total_questions)
print(f'Train size={X_train.shape}, test size={X_test.shape}')
print(f'Max players features, train: {X_train.max(axis=0)[-9:]}, test: {X_test.max(axis=0)[-9:]}')
Train size=(451783, 16), test size=(112841, 16) Max players features, train: [0.6875 5.420535 0.67804878 9.12249228 0.97222222 1. 0.48886945 1. 1. ], test: [0.3362963 5.42495002 0.27552213 9.12641514 0.97222222 1. 0.4510105 1. 1. ] CPU times: user 11.3 s, sys: 261 ms, total: 11.6 s Wall time: 11.4 s
%%time
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error
linear_model = LinearRegression()
linear_model.fit(X_train, y_train)
y_predict = linear_model.predict(X_test)
print(f'MSE={mean_squared_error(y_test, y_predict)}')
MSE=0.023110748853714367 CPU times: user 763 ms, sys: 183 ms, total: 946 ms Wall time: 884 ms
Так как для каждого игрока мы умеем предсказывать вероятность ответа на вопрос на турнире (условно "турнирный рейтинг игрока"), то для предсказания ранга команд можно поступить несколькими способами:
Воспользуемся третьим способом. Турнирный счет команды оценим как мат ожидания числа правильных ответов: $$R(team, tournament) = \sum_{q=1}^{Q}{1 - \prod_i(1-p_i)}=Q*(1 - \prod_i(1-p_i))$$
Так как множитель Q одинаковый для всех и не зависит от команд, то его можно опустить. Далее, так как мы хотим получить корреляцию между рангом и позицией, то лучше - меньший ранг -R. Финально: $$R(team, tournament) = - (1 - \prod_i(1-p_i)) \simeq \prod_i(1-p_i)$$
from scipy.stats import spearmanr, kendalltau
def calculate_corr(df, predict_function):
global results, total_cups, total_questions
spearman_corr = []
kendall_corr = []
for cup_id in df.sort_values('dateStart').id:
if len(results[cup_id]['teams_results']) < 2:
continue
cup_features = get_cup_features(cup_id)
teams = defaultdict(list)
for player_id, player_data in results[cup_id]['players_results'].items():
player_features = get_player_features(player_id, total_cups, total_questions)
features = np.append(cup_features, player_features)
p = predict_function(features)
teams[player_data['team']].append(p)
predicted = []
actual = []
for team_id, probs in teams.items():
rank = np.prod(1-np.array(probs))
predicted.append(rank)
actual.append(results[cup_id]['teams_results'][team_id])
# print(predicted, actual, results[cup_id])
spearman_corr.append(spearmanr(predicted, actual)[0])
kendall_corr.append(kendalltau(predicted, actual)[0])
return np.array(spearman_corr), np.array(kendall_corr)
%%time
predict_lm = lambda features: linear_model.predict(features.reshape(1, features.size))
spearman_corr, kendall_corr = calculate_corr(cups[cups.year == 2020], predict_lm)
print(f'LinearModel results: spearman_corr={spearman_corr.mean()}, kendall_corr={kendall_corr.mean()}')
LinearModel results: spearman_corr=0.6917857045297343, kendall_corr=0.5344105705631873 CPU times: user 8.74 s, sys: 57 ms, total: 8.79 s Wall time: 7.55 s
Теперь постараемся учесть командный характер соревнования: участники выступают в команде, и мы знаем только ответила ли команда на вопрос или нет. Будем считать, что если хотя бы один из участников дал правильный ответ, то и команда дала правильный ответ (здесь мы предположили, что из всех мнений почему-то команда выбирает правильный с гарантией). Это нам дает такой результат: если команда дала неверный ответ, то значит ни один из участников не дал правильного ответа.
Полная вероятность при ответе на i-ый вопрос командой из K участников: $$p = p_1^ip_2^i...p_K^i + (1-p_1^i)p_2^i...p_K^i + (1-p_1^i)(1-p_2^i)...(1-p_K^i)$$ Где за $p_k^i$ обозначена вероятность ответить участника k на i-ый вопрос.
Если бы у нас были скрытые переменные $z_k^i$ - знал ли участник k ответ на i-ый вопрос, то было бы легко записать правдоподобие: $$LH(x_i, z_i) = \prod_k (p_k(x_i))^{z_i^k} * (1 - p_k(x_i))^{1-z_i^k}$$
И для всех данных (всех вопросов i) $$LH(x, z) = \prod_i \prod_k (p_k(x_i))^{z_i^k} * (1 - p_k(x_i))^{1-z_i^k}$$ $$log LH(x, z) = \sum_i \sum_k z_i^k p_k(x_i) + (1-z_i^k)(1 - p_k(x_i))$$
Заметим теперь, что в нашей модели $p_k(x_i)$ не зависит от конкретного вопроса, а только от соревнования, в котором появился данный вопрос. Следовательно, логарифм правдоподобия можно переписать: $$log LH(x, z) = \sum_c^{cup} \sum_q^{questions} \sum_m^{member} z_q^m p_m(x_c) + (1-z_q^m)(1 - p_m(x_c))$$
Будем пытаться предсказать вероятность $p_m(x_c)$ с помощью сигмоиды: $p_m(x_c) = \sigma(w, \eta(x_c, m))$
Для оценки $z_q^m$ будем использовать знание о том, был ли ответ команды ($y_q$) засчитан. Если ответа команды не было, то будем считать $z_q^m=0$ исходя из предположений модели. Если же был, то оценим $z_q^m$ как вероятность дать правильный ответ игроком: $$z_q^m = \begin{cases} \sigma(w, \eta(x_c, m)), & \mbox{if } y_q\mbox{ = 1} \\ 0, & \mbox{if } y_q\mbox{ = 0} \end{cases}$$
Подставив предложенный выше $z_q^m$ в логарифм правдоподобия, и учтя, что $p_m(x_c)$ не меняется в рамках соревнования, получим: $$log LH(x, z) = \sum_c^{cup} n_{y_q=1} (\sum_m^{member} z_q^m p_m(x_c) + (1-z_q^m)(1 - p_m(x_c))) + n_{y_q=0}(\sum_m^{member}1 - p_m(x_c)) = $$ $$=\sum_c^{cup}\sum_m^{member} n_{y_q=1}[p_m(x_c)(2z_q^m-1) + 1 - z_q^m] + n_{y_q=0}[1 - p_m(x_c)]$$ $$=\sum_c^{cup}\sum_m^{member} p_m(x_c)(n_{y_q=1}(2z_q^m-1)-n_{y_q=0})+n_{y_q=1}(1-z_q^m) + n_{y_q=0}$$
Таким образом получили EM-алгоритм:
Expectation: $$z_q^m = \begin{cases} \sigma(\eta(x_c, m)), & \mbox{if } y_q\mbox{ = 1} \\ 0, & \mbox{if } y_q\mbox{ = 0} \end{cases}$$
Maximization: $$log LH(x, z) = \sum_c^{cup}\sum_m^{member} \sigma(w, \eta(x_c, m))(n_{y_q=1}(2z_q^m-1)-n_{y_q=0})+n_{y_q=1}(1-z_q^m) + n_{y_q=0} \rightarrow max_w$$
Теперь постараемся учесть командный характер соревнования: участники выступают в команде, и мы знаем только ответила ли команда на вопрос или нет. Будем считать, что если хотя бы один из участников дал правильный ответ, то и команда дала правильный ответ (здесь мы предположили, что из всех мнений почему-то команда выбирает правильный с гарантией). Это нам дает такой результат: если команда дала неверный ответ, то значит ни один из участников не дал правильного ответа.
Хочется уметь предсказывать вероятность правильного ответа игрока на любой вопрос: $$p(y=1|x)=\sigma(\eta(x))=\frac{1}{1+e^{-\eta(x)}}$$
Однако, у нас есть для обучения некоторая подвыборка вопросов. Если обозначить за $s=1$ событие "попадание вопроса в выборку", то можно записать пропорции сэмплирования (вероятность попадания в выборку вопроса, на который игрок не ответит или ответит соответственно): $$\gamma_0 = p(s=1|y=0)=\frac{n_0}{(1-\pi)N}$$ $$\gamma_1 = p(s=1|y=1)=\frac{n_1}{\pi N}$$
Где $\pi$ - доля ответов, на которые может ответить участник.
Тогда: $$p(y=1|s=1,x)=\frac{p(s=1|y=1,x)p(y=1|x)}{p(s=1|y=0,x)p(y=0|x)+p(s=1|y=1,x)p(y=1|x)}=\frac{1}{\frac{p(s=1|y=0,x)(1-p(y=1|x))}{p(s=1|y=1,x)p(y=1|x)} + 1}=\frac{1}{\frac{\gamma_0}{\gamma_1}e^{-\eta(x)} + 1}=\frac{1}{e^{-\eta(x) - ln \frac{\gamma_1}{\gamma_0}} + 1}=\sigma(\eta(x)+ln \frac{\gamma_1}{\gamma_0})$$
Для нашего случая, мы имеем $n_n$ ($n_n \equiv n_{not\_correct}$) - количество наблюдаемых неправильных ответов игрока (равных в точности количеству неправильных ответов команды). Кроме этого, мы имеем некоторую вероятность правильного ответа игрока при условии правильного ответа команды: $\pi n_c$ ($n_c \equiv n_{correct}$), соответсвенно:
$$p(y=0|s=1)p(s=1)=\frac{n_n+(1-\pi)n_c}{n_c + n_n}; p(y=1|s=1)=\frac{\pi n_c}{n_c + n_n}$$$$\gamma_0 = p(s=1|y=0)=\frac{p(y=0|s=1)p(s=1)}{p(y=0)}=\frac{n_n+(1-\pi)n_c}{(n_c + n_n)(1-\pi)}p(s=1)$$$$\gamma_1 = p(s=1|y=1)=\frac{p(y=1|s=1)p(s=1)}{p(y=1)}=\frac{n_c}{n_c + n_n}p(s=1)$$Так как нас интересует только их отношение, то:
$$\frac{\gamma_1}{\gamma_0}=\frac{n_c(1-\pi)}{n_n+(1-\pi)n_c}$$Таким образом можно построить EM-алгоритм:
Expectation: Будем подмешивать данные из экспепримента (знания об ответе команды на вопрос): $$y_i^{(k)} = \begin{cases} \sigma(\eta(x_i) + ln \frac{n_c(1-\pi)}{n_n+(1-\pi)n_c}), & \mbox{if } z_i\mbox{ = 1} \\ 0, & \mbox{if } z_i\mbox{ = 0} \end{cases}$$
Maximization: По полученным на предыдущем шаге будем обучать параметры модели: $$X, y^{(k)} \rightarrow \sigma(\eta(x))$$
# AdditionalData = namedtuple('AdditionalData', ['n_c', 'n_n', 'pi'])
def generate_data_em(df, do_update, total_cups=None, total_questions=None):
X, y, weights, additional_data = [], [], [], []
if do_update:
total_cups, total_questions = 0, 0
for cup_id in df.sort_values('dateStart').id:
cup_features = get_cup_features(cup_id)
if do_update:
total_cups += 1
total_questions += results[cup_id]['questions_number']
for player_id, player_data in results[cup_id]['players_results'].items():
player_features = get_player_features(player_id, total_cups, total_questions)
player = PLAYER_FEATURES[player_id]
pi = player.correct_n / player.questions_n if player.questions_n else 0.5
n_c = player_data['answer'].count('1')
n_n = player_data['answer'].count('0')
X.append(np.append(cup_features, player_features))
weights.append(n_n)
additional_data.append([n_c, n_n, pi])
X.append(np.append(cup_features, player_features))
weights.append(n_c)
additional_data.append([n_c, n_n, pi])
y.append(0)
y.append(1)
if do_update:
update_features(player_id, cup_id, player_data['position'], player_data['answer'])
return (np.array(X), np.array(y), np.array(weights),
np.array(additional_data), total_cups, total_questions)
%%time
reset_player_features()
X_train, y_train, weights_train, additional_data, total_cups, total_questions = generate_data_em(
cups[cups.year == 2019], do_update=True
)
print(f'Train size={X_train.shape}, total_cups={total_cups}, total_questions={total_questions}')
print(f'Max players features, train: {X_train.max(axis=0)[-9:]}, test: {X_test.max(axis=0)[-9:]}')
Train size=(903566, 16), total_cups=675, total_questions=33373 Max players features, train: [0.6875 5.420535 0.67804878 9.12249228 0.97222222 1. 0.48886945 1. 1. ], test: [0.3362963 5.42495002 0.27552213 9.12641514 0.97222222 1. 0.4510105 1. 1. ] CPU times: user 14.4 s, sys: 314 ms, total: 14.7 s Wall time: 14.6 s
### Реализация EM-шагов
def sigmoid(x):
return 1/(1+np.exp(-x))
def e_step(X_features, y_command, w, additional_data):
n_c, n_n, pi = additional_data[:, 0], additional_data[:, 1], additional_data[:, 2]
bias = (n_c * (1 - pi)) / (n_n + (1-pi)*n_c)
prediction = sigmoid(X_features @ w + bias)
return np.minimum(prediction, y_command)
def m_step(X_features, y, weights):
epsilon = 1e-6
target = np.log(np.maximum(y, epsilon)/np.maximum(1-y, epsilon))
model = LinearRegression()
model.fit(X_features, target, sample_weight=weights)
return model.coef_
Воспользуемся предыдущим способом построения ранга команд на основе вероятностей ответов игроков. Только теперь предскажем вероятность с помощью полученной модели в EM алгоритме
%%time
w = np.random.normal(size=X_train.shape[1])
w_hist = []
DELTA_THRESHOLD = 1e-5
for i in range(100):
verbose = i % 5 == 0
y = e_step(X_train, y_train, w, additional_data)
w_next = m_step(X_train, y, weights_train)
delta = np.linalg.norm(w_next-w)
if verbose:
print(f'Step {i+1}, |w_next - w|={delta}, |w|={np.linalg.norm(w)}')
w = w_next
w_hist.append(w)
if delta < DELTA_THRESHOLD:
break
print(f'Weights={w}')
predict_em = lambda features: sigmoid(features @ w)
spearman_corr, kendall_corr = calculate_corr(cups[cups.year == 2020], predict_em)
print(f'EM results: spearman_corr={spearman_corr.mean()}, kendall_corr={kendall_corr.mean()}')
Step 1, |w_next - w|=11.404323849111126, |w|=4.548663302795372 Step 6, |w_next - w|=0.47836675622859043, |w|=19.50588309896809 Step 11, |w_next - w|=0.031084855086779186, |w|=20.336885348711075 Step 16, |w_next - w|=0.0019964438014197566, |w|=20.382318277811006 Step 21, |w_next - w|=0.00012293347347254612, |w|=20.38486724158334 Step 26, |w_next - w|=7.304196869818073e-06, |w|=20.38501118749863 Weights=[ 0.00000000e+00 -3.44812062e+00 -2.30264909e+00 -1.03286903e+00 2.38268133e+00 -9.08373232e-01 1.86121002e+00 -3.18784896e+00 1.67738031e+00 3.67404792e+00 -5.06982253e-01 1.38185421e+01 -1.05630282e+01 3.74125728e+00 3.86292581e-03 6.63818776e+00] EM results: spearman_corr=0.7646358101496251, kendall_corr=0.6028877889699915 CPU times: user 1min 3s, sys: 3.43 s, total: 1min 6s Wall time: 21.3 s
%%time
spearman_hist, kendall_hist = [], []
for w_temp in w_hist:
predict_em = lambda features: sigmoid(features @ w_temp)
spearman_corr, kendall_corr = calculate_corr(cups[cups.year == 2020], predict_em)
spearman_hist.append(spearman_corr)
kendall_hist.append(kendall_corr)
CPU times: user 1min 3s, sys: 206 ms, total: 1min 3s Wall time: 1min 3s
(np.arange(len(spearman_hist))+1).shape
(26,)
plot_data = pd.DataFrame(dict(
iteration=np.arange(len(spearman_hist))+1,
spearman=np.array(spearman_hist).mean(axis=1),
kendall=np.array(kendall_hist).mean(axis=1)
))
px.line(plot_data, x="iteration", y=['spearman', 'kendall'], title='EM algorithm metrics by iterations')
Для определения сложности вопроса - посчитаем для вопроса его ранк как: $$f(q) = \sum_{p \in \{not answered\}}r_p - \sum_{q \in \{answered\}}(1-r_p)$$ где ранк игрока возьмем как доля правильных ответов игрока: $$r_p = \frac{n_{correct}}{n_{correct} + n_{incorrect}}$$
cups.head()
| id | name | dateStart | dateEnd | type | season | orgcommittee | synchData | questionQty | year | competitors | cup_type | duration | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 4772 | Синхрон северных стран. Зимний выпуск | 2019-01-05 19:00:00+03:00 | 2019-01-09 19:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/52 | [{'id': 28379, 'name': 'Константин', 'patronym... | {'dateRequestsAllowedTo': '2019-01-09T23:59:59... | {'1': 12, '2': 12, '3': 12} | 2019 | 1.0 | 3 | 0.011545 |
| 1 | 4957 | Синхрон Биркиркары | 2020-02-21 00:00:00+03:00 | 2020-02-27 23:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/53 | [{'id': 2421, 'name': 'Ася', 'patronymic': 'Се... | {'dateRequestsAllowedTo': '2020-02-27T18:00:00... | {'1': 13, '2': 13, '3': 13} | 2020 | 1.0 | 3 | 0.020084 |
| 2 | 4973 | Балтийский Берег. 3 игра | 2019-01-25 19:05:00+03:00 | 2019-01-29 19:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/52 | [{'id': 23030, 'name': 'Марина', 'patronymic':... | {'dateRequestsAllowedTo': '2019-01-28T23:59:59... | {'1': 12, '2': 12, '3': 12} | 2019 | 1.0 | 3 | 0.011535 |
| 3 | 4974 | Балтийский Берег. 4 игра | 2019-03-01 19:05:00+03:00 | 2019-03-05 19:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/52 | [{'id': 23030, 'name': 'Марина', 'patronymic':... | {'dateRequestsAllowedTo': '2019-03-04T23:59:59... | {'1': 12, '2': 12, '3': 12} | 2019 | 1.0 | 3 | 0.011535 |
| 4 | 4975 | Балтийский Берег. 5 игра | 2019-04-05 19:05:00+03:00 | 2019-04-09 19:00:00+03:00 | {'id': 3, 'name': 'Синхрон'} | /seasons/52 | [{'id': 23030, 'name': 'Марина', 'patronymic':... | {'dateRequestsAllowedTo': '2019-04-08T23:59:59... | {'1': 12, '2': 12, '3': 12} | 2019 | 1.0 | 3 | 0.011535 |
%%time
not_in_stat = 0
questions_difficulty = []
for cup_id, name in cups[['id', 'name']].values:
ranks = [0] * results[cup_id]['questions_number']
participants = 0
for player_id, player_data in results[cup_id]['players_results'].items():
player = PLAYER_FEATURES[player_id]
if not player.questions_n:
not_in_stat += 1
continue
player_rank = player.correct_n / player.questions_n
participants += 1
for i, is_answered in enumerate(player_data['answer']):
if i >= len(ranks):
ranks += [0] * (1 + i-len(ranks))
if is_answered:
ranks[i] -= (1 - player_rank)
else:
ranks[i] += player_rank
questions_difficulty += [{
'sequence_number': i+1,
'cup': name,
'rank': rank,
'participants': participants
} for i, rank in enumerate(ranks)]
questions_difficulty_df = pd.DataFrame(questions_difficulty).sort_values('rank', ascending=False)
cups_difficulty = questions_difficulty_df.drop(columns='sequence_number').groupby("cup").mean().sort_values('rank', ascending=False)
questions_difficulty_df[questions_difficulty_df.participants > 20].head(20)
CPU times: user 7.13 s, sys: 24.4 ms, total: 7.15 s Wall time: 7.16 s
| sequence_number | cup | rank | participants | |
|---|---|---|---|---|
| 12694 | 211 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12687 | 204 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12688 | 205 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12689 | 206 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12690 | 207 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12691 | 208 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12692 | 209 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12693 | 210 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12697 | 214 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12695 | 212 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12696 | 213 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12685 | 202 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12698 | 215 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12699 | 216 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12700 | 217 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12701 | 218 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12704 | 221 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12686 | 203 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12684 | 201 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| 12683 | 200 | Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
questions_difficulty_df[questions_difficulty_df.participants > 20][-20:]
| sequence_number | cup | rank | participants | |
|---|---|---|---|---|
| 39072 | 16 | ОВСЧ | -6985.233629 | 11988 |
| 39082 | 26 | ОВСЧ | -6985.233629 | 11988 |
| 39074 | 18 | ОВСЧ | -6985.233629 | 11988 |
| 39075 | 19 | ОВСЧ | -6985.233629 | 11988 |
| 39092 | 36 | ОВСЧ | -6985.233629 | 11988 |
| 39090 | 34 | ОВСЧ | -6985.233629 | 11988 |
| 39089 | 33 | ОВСЧ | -6985.233629 | 11988 |
| 39088 | 32 | ОВСЧ | -6985.233629 | 11988 |
| 39087 | 31 | ОВСЧ | -6985.233629 | 11988 |
| 39086 | 30 | ОВСЧ | -6985.233629 | 11988 |
| 39085 | 29 | ОВСЧ | -6985.233629 | 11988 |
| 39084 | 28 | ОВСЧ | -6985.233629 | 11988 |
| 39083 | 27 | ОВСЧ | -6985.233629 | 11988 |
| 39081 | 25 | ОВСЧ | -6985.233629 | 11988 |
| 39080 | 24 | ОВСЧ | -6985.233629 | 11988 |
| 39079 | 23 | ОВСЧ | -6985.233629 | 11988 |
| 39078 | 22 | ОВСЧ | -6985.233629 | 11988 |
| 39077 | 21 | ОВСЧ | -6985.233629 | 11988 |
| 39076 | 20 | ОВСЧ | -6985.233629 | 11988 |
| 39091 | 35 | ОВСЧ | -6985.233629 | 11988 |
Примерно соответствует ожиданиям
cups_difficulty[cups_difficulty.participants > 20][:10]
| rank | participants | |
|---|---|---|
| cup | ||
| Пятая октава: Тропик Козерога. Лига Наций: Беларус | -10.360679 | 22.0 |
| Чацвёртая актава. Ліга нацый: Беларусь | -11.780863 | 23.0 |
| Второй Карагандинский марафон | -12.569244 | 23.0 |
| Чёрная Быль | -13.335561 | 26.0 |
| Седьмая октава: Кубок Равноденствия. Лига Наций: Беларусь | -13.960734 | 26.0 |
| Шестая октава: СИ-Мажор. Лига Наций: Беларусь | -14.114771 | 26.0 |
| Асинхрон по South Park | -14.482307 | 31.0 |
| Lida Major. День 1 | -14.877261 | 29.0 |
| Октавы: Гала-турнир. Лига Наций: Беларусь | -14.921897 | 28.0 |
| Шестой киевский марафон. Асинхрон | -16.133308 | 32.0 |
# простые
cups_difficulty[cups_difficulty.participants > 20][-10:]
| rank | participants | |
|---|---|---|
| cup | ||
| Школьная лига. I тур. | -2772.096341 | 3989.0 |
| Балтийский Берег. 5 игра | -2793.139609 | 4955.0 |
| ОВСЧ. 1 этап | -2888.188803 | 5130.0 |
| Балтийский Берег. 4 игра | -2921.480441 | 5213.5 |
| Кубок городов | -2928.740812 | 5180.0 |
| ОВСЧ. 2 этап | -3015.154699 | 5323.0 |
| Балтийский Берег. 3 игра | -3036.898869 | 5460.5 |
| Балтийский Берег. 1 игра | -3574.883197 | 6259.0 |
| ОВСЧ | -3820.791127 | 11988.0 |
| Балтийский Берег. Общий зачёт | -3971.610485 | 11399.0 |
Можно также оценивать сложность турнира по сложности самого трудного вопроса
cups_difficulty_by_max = questions_difficulty_df.drop(columns='sequence_number').groupby("cup").max().sort_values('rank', ascending=False)
cups_difficulty_by_max[cups_difficulty_by_max.participants > 20][:10]
| rank | participants | |
|---|---|---|
| cup | ||
| Гран-при Славянки. Общий зачёт | -9.259532 | 2972 |
| Пятая октава: Тропик Козерога. Лига Наций: Беларус | -10.360679 | 22 |
| Чацвёртая актава. Ліга нацый: Беларусь | -11.780863 | 23 |
| Второй Карагандинский марафон | -12.569244 | 23 |
| Чёрная Быль | -13.335561 | 26 |
| Седьмая октава: Кубок Равноденствия. Лига Наций: Беларусь | -13.960734 | 26 |
| Шестая октава: СИ-Мажор. Лига Наций: Беларусь | -14.114771 | 26 |
| Асинхрон по South Park | -14.482307 | 31 |
| Lida Major. День 1 | -14.877261 | 29 |
| Октавы: Гала-турнир. Лига Наций: Беларусь | -14.921897 | 28 |
cups_difficulty_by_max[cups_difficulty_by_max.participants > 20][-10:]
| rank | participants | |
|---|---|---|
| cup | ||
| ОВСЧ. 3 этап | -2752.611024 | 4829 |
| Балтийский Берег. 2 игра | -2759.458304 | 4948 |
| Школьная лига. I тур. | -2772.096341 | 3989 |
| Балтийский Берег. 5 игра | -2793.139609 | 4955 |
| Балтийский Берег. 4 игра | -2884.740371 | 5236 |
| ОВСЧ. 1 этап | -2888.188803 | 5130 |
| Кубок городов | -2928.740812 | 5180 |
| Балтийский Берег. 3 игра | -2956.243445 | 5649 |
| ОВСЧ. 2 этап | -3015.154699 | 5323 |
| Балтийский Берег. 1 игра | -3574.883197 | 6259 |